iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

拒當 API 韭菜!30 天手邊設備玩轉 AI Agent系列 第 3

Day 03】MacBook Pro 推論引擎 該選 MLX 還是 llama.cpp?

  • 分享至 

  • xImage
  •  

上一篇看完 MacBook Pro M3 Max 的硬體規格之後,今天終於要實際把模型丟進去跑了。

這次我想回答的問題很單純:

之後在這台 MacBook Pro 上做 Local AI Agent,我到底該用 llama.cpp,還是 MLX?

Mac 上常見的 Local LLM 方案主要有:

  1. Ollama
  2. llama.cpp
  3. MLX

Ollama 本身就是建立在 llama.cpp 生態之上的封裝,所以這次我不另外拿它來做底層效能比較,而是直接測兩條不同的技術路線:

  • llama.cpp:成熟、通用,GGUF 生態很完整
  • MLX:Apple Silicon 原生體系,針對 Unified Memory 設計

這篇也先不碰 Agent、RAG、Tool Calling,只測最基本的:

Prompt → LLM → Response

先把純推論效能搞清楚,再往後面的 Agent 實驗疊上去。


這次看哪些指標?

我主要看三個數字:

  • TTFT(Time to First Token):送出 Prompt 到看到第一個 Token 的時間
  • Generation Speed:開始生成後,每秒可以吐多少 Token
  • Peak Memory:推論期間的峰值記憶體

其中我最在意的是前兩個。

因為之後做 Agent,一次任務可能會呼叫模型很多次,TTFT 會一直累積;Generation Speed 則直接決定實際互動起來順不順。


測試環境

這次使用:

  • MacBook Pro
  • Apple M3 Max
  • 16-core CPU
  • 40-core GPU
  • 64GB Unified Memory

推論方案:

  • llama.cpp
  • MLX / mlx-lm

我另外包了一個簡單的 FastAPI,讓兩套引擎都走同一個介面,再統一量 TTFT、生成速度與記憶體。


安裝與模型準備

llama.cpp 這邊使用 llama-cpp-python

CMAKE_ARGS="-DGGML_METAL=on" pip install llama-cpp-python

MLX:

pip install mlx mlx-lm fastapi "uvicorn[standard]" requests

這次測四顆模型:

hf download unsloth/Qwen3.8-27B-GGUF \
  Qwen3.8-27B-UD-Q4_K_M.gguf --local-dir ./models

hf download unsloth/gemma-4-12b-it-GGUF \
  gemma-4-12b-it-Q4_K_M.gguf --local-dir ./models

hf download ggml-org/gpt-oss-20b-GGUF \
  gpt-oss-20b-MXFP4.gguf --local-dir ./models

hf download bartowski/google_gemma-4-26B-A4B-it-GGUF \
  google_gemma-4-26B-A4B-it-Q4_K_M.gguf --local-dir ./models

統一推論介面

# engines.py —— 兩套推論引擎的統一介面
import gc
import time
import resource
from dataclasses import dataclass


@dataclass
class InferMetrics:
    prompt_tokens: int = 0
    completion_tokens: int = 0
    ttft: float = 0.0
    gen_tok_s: float = 0.0
    total_time: float = 0.0
    peak_mem_gb: float = 0.0


@dataclass
class InferResult:
    text: str
    metrics: InferMetrics


def _peak_mem_gb() -> float:
    # macOS 上 ru_maxrss 單位是 bytes,而且是「整個 process 從啟動到現在」的歷史最高值,
    # 不會隨著模型被換掉而下降——這點在下面的踩坑段落會再解釋。
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1024 ** 3)


class LlamaCppEngine:
    """透過 llama-cpp-python 載入 GGUF 模型,n_gpu_layers=-1 代表全部 layer 都丟給 Metal。

    快取只留「目前這一顆」模型:換模型之前先把舊的丟掉、觸發垃圾回收,
    釋放 Metal 那邊配置的 GPU 記憶體,不然連續測好幾顆模型會把統一記憶體榨乾。
    """

    def __init__(self):
        self._cache = {}

    def _evict(self):
        self._cache.clear()
        gc.collect()

    def _get_model(self, model_path: str, n_ctx: int = 4096):
        from llama_cpp import Llama

        if model_path in self._cache:
            return self._cache[model_path]

        self._evict()
        llm = Llama(model_path=model_path, n_gpu_layers=-1, n_ctx=n_ctx, verbose=False)
        self._cache = {model_path: llm}
        return llm

    def infer(self, model_path: str, prompt: str, max_tokens: int = 2048) -> InferResult:
        llm = self._get_model(model_path)
        t0 = time.perf_counter()
        ttft, n_tokens, chunks = None, 0, []

        stream = llm.create_chat_completion(
            messages=[{"role": "user", "content": prompt}],
            max_tokens=max_tokens,
            stream=True,
        )
        for chunk in stream:
            delta = chunk["choices"][0]["delta"].get("content")
            if delta:
                if ttft is None:
                    ttft = time.perf_counter() - t0
                n_tokens += 1
                chunks.append(delta)

        t_total = time.perf_counter() - t0
        gen_tok_s = (n_tokens - 1) / (t_total - ttft) if n_tokens > 1 and ttft else 0.0
        prompt_tokens = len(llm.tokenize(prompt.encode("utf-8")))

        return InferResult(
            text="".join(chunks),
            metrics=InferMetrics(prompt_tokens, n_tokens, ttft or 0.0, gen_tok_s, t_total, _peak_mem_gb()),
        )


class MLXEngine:
    """透過 mlx-lm 的 stream_generate 做推論。

    快取邏輯跟 LlamaCppEngine 一樣只留目前這一顆;記憶體用量改用 MLX 自己的
    峰值計數器(可以在每次推論前重置),比 process 層級的 ru_maxrss 更準。
    """

    def __init__(self):
        self._cache = {}

    def _evict(self):
        self._cache.clear()
        gc.collect()
        try:
            import mlx.core as mx
            mx.clear_cache()  # 把 MLX 內部保留、還沒真的還給系統的記憶體池清掉
        except Exception:
            pass

    def _get_model(self, model_id: str):
        from mlx_lm import load

        if model_id in self._cache:
            return self._cache[model_id]

        self._evict()
        entry = load(model_id)
        self._cache = {model_id: entry}
        return entry

    def _mlx_peak_mem_gb(self) -> float:
        import mlx.core as mx

        # 不同版本的 mlx-lm,這組 API 有時候在 mx 底下、有時候在 mx.metal 底下,兩個都試一次。
        for get_peak, reset_peak in (
            (getattr(mx, "get_peak_memory", None), getattr(mx, "reset_peak_memory", None)),
            (getattr(getattr(mx, "metal", None), "get_peak_memory", None),
             getattr(getattr(mx, "metal", None), "reset_peak_memory", None)),
        ):
            if get_peak is not None:
                peak_bytes = get_peak()
                if reset_peak is not None:
                    reset_peak()
                return peak_bytes / (1024 ** 3)
        return _peak_mem_gb()  # 都拿不到就退回 process 層級的數字

    def infer(self, model_id: str, prompt: str, max_tokens: int = 2048) -> InferResult:
        from mlx_lm.generate import stream_generate

        model, tokenizer = self._get_model(model_id)
        formatted = tokenizer.apply_chat_template(
            [{"role": "user", "content": prompt}], add_generation_prompt=True
        )
        prompt_tokens = len(tokenizer.encode(prompt))

        t0 = time.perf_counter()
        ttft, n_tokens, chunks = None, 0, []

        for resp in stream_generate(model, tokenizer, formatted, max_tokens=max_tokens):
            if ttft is None:
                ttft = time.perf_counter() - t0
            n_tokens += 1
            chunks.append(resp.text)

        t_total = time.perf_counter() - t0
        gen_tok_s = (n_tokens - 1) / (t_total - ttft) if n_tokens > 1 and ttft else 0.0

        return InferResult(
            text="".join(chunks),
            metrics=InferMetrics(prompt_tokens, n_tokens, ttft or 0.0, gen_tok_s, t_total, self._mlx_peak_mem_gb()),
        )


ENGINES = {"llama.cpp": LlamaCppEngine(), "mlx": MLXEngine()}

FastAPI:

# app.py —— 服務本體:POST /v1/infer,回傳生成內容 + 這次推論的指標
from fastapi import FastAPI
from pydantic import BaseModel
from engines import ENGINES

app = FastAPI(title="Local Inference Bench Service")


class InferRequest(BaseModel):
    engine: str          # "llama.cpp" 或 "mlx"
    model: str           # llama.cpp: 本機 GGUF 路徑;mlx: HuggingFace repo id
    prompt: str
    max_tokens: int = 2048


@app.post("/v1/infer")
def infer(req: InferRequest):
    engine = ENGINES.get(req.engine)
    if engine is None:
        return {"error": f"未知的 engine:{req.engine},只支援 llama.cpp / mlx"}

    result = engine.infer(req.model, req.prompt, req.max_tokens)
    m = result.metrics
    return {
        "engine": req.engine,
        "model": req.model,
        "text": result.text,
        "metrics": {
            "prompt_tokens": m.prompt_tokens,
            "completion_tokens": m.completion_tokens,
            "ttft_s": round(m.ttft, 3),
            "gen_tok_s": round(m.gen_tok_s, 2),
            "total_time_s": round(m.total_time, 3),
            "peak_mem_gb": round(m.peak_mem_gb, 2),
        },
    }

啟動:

uvicorn app:app --host 127.0.0.1 --port 8000

Benchmark 怎麼跑?

每組不是只跑一次,而是:

  • 先 Warm-up 1 次,不計入結果
  • 正式跑 10 次
  • 取平均與標準差

測試 Prompt 固定:

請用三句話說明什麼是統一記憶體架構

完整測試腳本:

# run_bench.py —— 依序呼叫服務,每組跑熱身 + 多次正式量測,取平均與標準差
import csv
import statistics
import requests

SERVICE_URL = "http://127.0.0.1:8000/v1/infer"
PROMPT = "請用三句話說明什麼是統一記憶體架構"
MAX_TOKENS = 2048
WARMUP_RUNS = 1    # 熱身次數,結果不計入
REPEAT_RUNS = 10   # 正式量測次數,取平均與標準差

# (顯示名稱, engine, model 路徑或 repo id)
JOBS = [
    ("Qwen3.8-27B", "llama.cpp", "./models/Qwen3.8-27B-UD-Q4_K_M.gguf"),
    ("Qwen3.8-27B", "mlx", "mlx-community/Qwen3.8-27B-4bit"),
    ("Gemma-4-12B-it", "llama.cpp", "./models/gemma-4-12b-it-Q4_K_M.gguf"),
    ("Gemma-4-12B-it", "mlx", "mlx-community/gemma-4-12B-it-4bit"),
    ("Gemma-4-26B-A4B-it", "llama.cpp", "./models/google_gemma-4-26B-A4B-it-Q4_K_M.gguf"),
    ("Gemma-4-26B-A4B-it", "mlx", "mlx-community/gemma-4-26b-a4b-it-4bit"),
    ("GPT-OSS-20B", "llama.cpp", "./models/gpt-oss-20b-MXFP4.gguf"),
    ("GPT-OSS-20B", "mlx", "mlx-community/gpt-oss-20b-MXFP4-Q8"),
]


def call_once(engine, model):
    resp = requests.post(
        SERVICE_URL,
        json={"engine": engine, "model": model, "prompt": PROMPT, "max_tokens": MAX_TOKENS},
        timeout=600,
    )
    return resp.json()["metrics"]


def run_job(name, engine, model):
    print(f"跑 {name} / {engine} ...")
    for i in range(WARMUP_RUNS):
        print(f"  熱身 {i + 1}/{WARMUP_RUNS}(結果不計入)")
        call_once(engine, model)

    ttfts, gen_speeds, peak_mems, prompt_tokens = [], [], [], None
    for i in range(REPEAT_RUNS):
        print(f"  正式量測 {i + 1}/{REPEAT_RUNS}")
        m = call_once(engine, model)
        ttfts.append(m["ttft_s"])
        gen_speeds.append(m["gen_tok_s"])
        peak_mems.append(m["peak_mem_gb"])
        prompt_tokens = m["prompt_tokens"]

    return {
        "model": name,
        "engine": engine,
        "prompt_tokens": prompt_tokens,
        "ttft_s_mean": round(statistics.mean(ttfts), 3),
        "ttft_s_stdev": round(statistics.stdev(ttfts), 3) if len(ttfts) > 1 else 0.0,
        "gen_tok_s_mean": round(statistics.mean(gen_speeds), 2),
        "gen_tok_s_stdev": round(statistics.stdev(gen_speeds), 2) if len(gen_speeds) > 1 else 0.0,
        "peak_mem_gb": round(max(peak_mems), 2),  # 峰值記憶體取這組裡的最大值,不是平均
        "note": "",
    }


def main():
    fieldnames = ["model", "engine", "prompt_tokens", "ttft_s_mean", "ttft_s_stdev",
                  "gen_tok_s_mean", "gen_tok_s_stdev", "peak_mem_gb", "note"]
    rows = []
    for name, engine, model in JOBS:
        try:
            rows.append(run_job(name, engine, model))
        except Exception as e:
            rows.append({"model": name, "engine": engine, "note": f"失敗:{e}"})

    with open("results.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)

    print(f"\n(每組跑 {WARMUP_RUNS} 次熱身 + {REPEAT_RUNS} 次正式量測,下表是平均值 ± 標準差)")
    print("| 模型 | 引擎 | Prompt Tokens | TTFT (s) | 生成速度 (tok/s) | 峰值記憶體 (GB) | 備註 |")
    print("| :--- | :--- | :---: | :---: | :---: | :---: | :--- |")
    for r in rows:
        if r.get("note"):
            print(f"| {r['model']} | {r['engine']} | - | - | - | - | {r['note']} |")
            continue
        print(f"| {r['model']} | {r['engine']} | {r['prompt_tokens']} | "
              f"{r['ttft_s_mean']} ± {r['ttft_s_stdev']} | {r['gen_tok_s_mean']} ± {r['gen_tok_s_stdev']} | "
              f"{r['peak_mem_gb']} | {r['note']} |")


if __name__ == "__main__":
    main()

實測結果

每組跑 1 次 Warm-up + 10 次正式量測。

模型 引擎 Prompt Tokens TTFT (s) 生成速度 (tok/s) 峰值記憶體 (GB) 備註
Qwen3.8-27B llama.cpp 9 0.411 ± 0.036 16.1 ± 0.76 15.65
Qwen3.8-27B mlx 9 0.648 ± 0.089 14.4 ± 4.14 14.44
Gemma-4-12B-it llama.cpp 13 0.039 ± 0.002 23.8 ± 1.04 30.27
Gemma-4-12B-it mlx - - - - 失敗:Expecting value: line 1 column 1 (char 0)
Gemma-4-26B-A4B-it llama.cpp 13 0.019 ± 0.001 51.57 ± 0.52 30.27
Gemma-4-26B-A4B-it mlx 12 0.227 ± 0.002 81.68 ± 1.94 13.43
GPT-OSS-20B llama.cpp 18 0.015 ± 0.001 67.14 ± 1.68 31.19
GPT-OSS-20B mlx 18 0.257 ± 0.001 83.67 ± 0.38 11.38

跑完之後,我看到幾件事

1. MLX 並沒有全面贏

Qwen3.8-27B 這組反而是 llama.cpp 比較快:

  • llama.cpp:16.1 ± 0.76 tok/s
  • MLX:14.4 ± 4.14 tok/s

TTFT 也是 llama.cpp 比較低。

所以不能因為 MLX 是 Apple 自家的框架,就直接認定它所有模型都會比較快。

2. MoE 在這台 Mac 上真的很有意思

兩顆 MoE 的生成速度都很漂亮。

Gemma-4-26B-A4B-it:

  • llama.cpp:51.57 ± 0.52 tok/s
  • MLX:81.68 ± 1.94 tok/s

GPT-OSS-20B:

  • llama.cpp:67.14 ± 1.68 tok/s
  • MLX:83.67 ± 0.38 tok/s

這也讓我更確定,之後挑 Local LLM 不能只看「幾 B」。

Dense 或 MoE、Active Parameters、量化方式,都會直接影響實際推論體感。

3. llama.cpp 的 TTFT 很漂亮

在 Gemma-4-26B-A4B-it 與 GPT-OSS-20B 上,llama.cpp 的 TTFT 分別只有:

  • 0.019 ± 0.001 s
  • 0.015 ± 0.001 s

這點對之後 Agent 很重要。

因為 Agent 一個任務可能會呼叫模型很多次,如果每次都要重新付一次 TTFT,差距累積起來就會很明顯。

4. 跑不起來,本身也是結果

Gemma-4-12B-it 的 MLX 版本這次直接失敗。

我沒有把它刪掉,因為對實際開發來說:

模型支援度本來就是推論框架的一部分。

llama.cpp 最大的優勢之一,還是在 GGUF 生態非常成熟,遇到特定模型時通常比較容易找到可以直接用的版本。


Peak Memory 先不要過度解讀

這次表格裡雖然有 Peak Memory,但我暫時不拿它直接判斷 MLX 與 llama.cpp 誰比較省。

原因是兩邊量測方式不同。

llama.cpp 目前用的是:

resource.getrusage(resource.RUSAGE_SELF).ru_maxrss

這個值代表的是整個 Process 從啟動以來的歷史最高記憶體使用量,不是單一模型、單次 inference 的乾淨峰值。

所以這次 Memory 數字先保留,未來如果真的要比較,我會改成每個模型獨立啟動 Process 再測一次。


最後,我會選哪一套?

如果後面的鐵人賽只能先選一個主要推論環境,我目前會選:

MLX 為主,llama.cpp 為輔。

原因不是 MLX 全面比較快,而是這次兩顆我比較有興趣的 MoE 模型,在 MLX 上都跑到 80 tok/s 左右,對之後做 Local Agent 很有吸引力。

但 llama.cpp 我一定會留下來。

因為:

  • Qwen3.8-27B 這次是 llama.cpp 比較快
  • llama.cpp 的 TTFT 很漂亮
  • GGUF 模型生態成熟
  • 某些 MLX 模型可能遇到相容性問題

所以後面的架構大概會是:

MacBook Pro
├── MLX
│   └── 主要推論環境
└── llama.cpp
    └── GGUF / 相容性 / fallback

這次測的還只是最單純的對話生成。

接下來才會開始加入 System Prompt、Tool Calling、RAG、Agent Loop,看現在的推論差異,到了真正的 Local Agent 任務裡還會不會成立。


上一篇
【Day 02】我手上到底有多少算力?M3 Max vs ASUS Ascent GX10 規格盤點
下一篇
【Day 04】Agent 面試現場:什麼模型才真的能幫我做事?
系列文
拒當 API 韭菜!30 天手邊設備玩轉 AI Agent4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言